Simple Momentum Strategy - Backtesting¶
In this notebook, we will try to answer a very simple question, and that is, can we generate profit by trading stocks at daily basis based on past day's return? meaning that if the return of a stock in the past day is positive, we buy the stock, and if it's negative, we just don't take any actions.
Strategy Logic
- At the end of each day, we check the day returns of at least 10 stocks (depending on the availability of data).
- We buy the stocks with positive returns at the closing price of the session, and ignore the stocks with negative rturns. The opened positions are equally weighted.
- The positions are kept opened overnight and throughout the next trading session.
- All opened positions are closed at the end of the next trading session, at the closing price.
Important Note: Opening positions at the end of the previous day's session ensures that positions take advantage of any overnight news, in contrast with opening the positions at the open price of the same day.
import pandas as pd
from datetime import timedelta
import datetime as dt
from utils import calculate_portfolio_measures, plot_portfolio_performance
import warnings
warnings.filterwarnings('ignore')
%load_ext autoreload
%autoreload 2
1. Data Preparation¶
We will use the current constituents of the EGX30 index as our universe of stocks for backtesting our strategy, and we will go back in time and start from the oldest day possible that satisfies the 10 stocks rule.
Using the OHLC data downloaded from TradingView, we can start the backtest as from May-2001, which is the first date at which we have historical data for at least 10 stocks of our stocks universe. As time goes by, the number of stocks traded every day increases as more data become available.
1.1. Loading stocks OHLC data¶
We organize the stocks OHLC data in a dataframe with a multi-index of the asset names and dates, this way, we get to batch process all stocks at once for the purposes precalulating indicators and filteration.
assets = [
'ADIB','ABUK','ALCN','AMOC','BTFH','COMI','SUGR','EFIH','EAST','EFID','HRHO',
'EKHO','PHAR','SWDY','ESRS','FWRY','GBCO','ISPH','JUFO','MASR','HELI','MFPC',
'ORHD','ORWE','PHDC','CCAP','SKPC','TMGH','ETEL'
]
data = pd.DataFrame()
for asset in assets:
asset_data = pd.read_csv(f'data/daily/ohlc/stocks/{asset}.csv')
asset_data['time'] = pd.to_datetime(asset_data.time)
asset_data['asset'] = [asset,]*asset_data.shape[0]
asset_data.set_index(['asset', 'time'], inplace=True)
asset_data.index.names = ['asset', 'date']
data = pd.concat([data, asset_data])
data.sort_index(inplace=True)
data
| open | high | low | close | ||
|---|---|---|---|---|---|
| asset | date | ||||
| ABUK | 2001-05-15 | 1.026908 | 1.036363 | 0.990545 | 1.030908 |
| 2001-05-16 | 0.990908 | 0.993090 | 0.981817 | 0.988363 | |
| 2001-05-17 | 0.981817 | 0.981817 | 0.963635 | 0.971999 | |
| 2001-05-20 | 0.963635 | 0.963635 | 0.956363 | 0.961090 | |
| 2001-05-21 | 0.958181 | 0.958181 | 0.945454 | 0.947999 | |
| ... | ... | ... | ... | ... | ... |
| TMGH | 2024-07-09 | 60.610001 | 62.200001 | 60.500000 | 61.990002 |
| 2024-07-10 | 61.990002 | 62.599998 | 60.700001 | 61.000000 | |
| 2024-07-14 | 61.000000 | 61.470001 | 58.279999 | 58.599998 | |
| 2024-07-15 | 58.599998 | 59.580002 | 58.320000 | 58.799999 | |
| 2024-07-16 | 58.800000 | 59.190000 | 57.730000 | 58.030000 |
121607 rows × 4 columns
1.2. Loading benchmark OHLC data¶
We will be using EGX30 index as benchmark for our strategy. The data is downloaded from TradingView.
egx30_index = pd.read_csv(f'data/daily/ohlc/indices/EGX30.csv')
egx30_index['time'] = pd.to_datetime(egx30_index.time)
egx30_index.set_index(['time'], inplace=True)
egx30_index.index.names = ['date']
egx30_index
| open | high | low | close | |
|---|---|---|---|---|
| date | ||||
| 1998-01-01 | 1000.000000 | 1000.250000 | 1000.000000 | 1000.250000 |
| 1998-01-04 | 1000.250000 | 1000.250000 | 998.390015 | 998.390015 |
| 1998-01-05 | 998.390015 | 998.390015 | 996.250000 | 996.250000 |
| 1998-01-06 | 996.250000 | 996.250000 | 992.869995 | 992.869995 |
| 1998-01-07 | 992.869995 | 992.869995 | 989.349976 | 989.349976 |
| ... | ... | ... | ... | ... |
| 2024-07-09 | 28411.400000 | 28519.801000 | 28258.900000 | 28466.199000 |
| 2024-07-10 | 28487.301000 | 28702.000000 | 28251.500000 | 28288.000000 |
| 2024-07-14 | 28492.301000 | 28531.801000 | 27997.801000 | 28042.100000 |
| 2024-07-15 | 28043.301000 | 28199.900000 | 27917.500000 | 27952.000000 |
| 2024-07-16 | 27952.700000 | 28095.200000 | 27785.700000 | 27828.900000 |
6471 rows × 4 columns
1.3. Calculating historical daily returns¶
We now proceed to calculate the historical daily returns for the stocks and benchmark, utilizing the following formula:
$$ r_i(t) = \frac{p_i(t) - p_i(t-1)}{p_i(t-1)} $$where:
- $r_i(t)$ is the return of stock $i$ at time $t$
- $p_i(t)$ is the price of stock $i$ at time $t$ (selling price)
- $p_i(t-1)$ is the price of stock $i$ at time $t-1$ (purchase price)
This is consitstent with the strategy logic mentioned before that we open positions at the close price of the previous day $t-1$. We could have used the open price at time $t$ as the purchase price, but this would ignore the effect of ov.ernight news
rets = data.sort_index().close.groupby(level=0).pct_change().dropna()
rets
asset date
ABUK 2001-05-16 -0.041270
2001-05-17 -0.016556
2001-05-20 -0.011223
2001-05-21 -0.013621
2001-05-22 -0.002685
...
TMGH 2024-07-09 0.022769
2024-07-10 -0.015970
2024-07-14 -0.039344
2024-07-15 0.003413
2024-07-16 -0.013095
Name: close, Length: 121578, dtype: float64
egx30_index_returns = egx30_index.close.pct_change().dropna()
egx30_index_returns
date
1998-01-04 -0.001860
1998-01-05 -0.002143
1998-01-06 -0.003393
1998-01-07 -0.003545
1998-01-08 -0.005023
...
2024-07-09 0.001911
2024-07-10 -0.006260
2024-07-14 -0.008693
2024-07-15 -0.003213
2024-07-16 -0.004404
Name: close, Length: 6470, dtype: float64
1.4. Filtering returns¶
In order to filter our returns from misleading data, we will apply the following filters:
- Continuity filter: Only daily returns that are calculated using consecutive business days are kept.
- Enough stocks filter: Only days with returns data for at least 10 stocks are considered.
## Select daily returns only (removes any return at a given day if it was calculated with respect to a previous close price before more than 3 days)
selected_indices = []
for idx in data.index:
one_day_before_available = (idx[0], str((idx[1]+timedelta(-1)).date())) in data.index
two_day_before_available = (idx[0], str((idx[1]+timedelta(-2)).date())) in data.index
three_day_before_available = (idx[0], str((idx[1]+timedelta(-3)).date())) in data.index
if one_day_before_available or two_day_before_available or three_day_before_available:
selected_indices.append(idx)
rets = rets.loc[selected_indices]
# Set any return of less than -10% to -10%
# rets.loc[rets<-0.1] = -0.1
## Set any return of more than 30% to 30%
# rets.loc[rets>0.3] = 0.3
## Remove any day with less than 10 positions
n_assets_per_day = rets.groupby(level=1).count()
days_with_enough_assets = n_assets_per_day.loc[n_assets_per_day>=10].index
rets = rets.loc[(slice(None),days_with_enough_assets)]
rets
asset date
ABUK 2001-05-23 0.029615
2001-05-24 0.008592
2001-05-29 -0.000370
2001-05-31 0.015920
2001-06-05 -0.003690
...
TMGH 2024-07-08 -0.009802
2024-07-09 0.022769
2024-07-10 -0.015970
2024-07-15 0.003413
2024-07-16 -0.013095
Name: close, Length: 115333, dtype: float64
2. Calculating Strategy Returns¶
Now that we have our returns calculated and filtered, we can proceed to calculating strategy returns.
2.1. Generating trading signals¶
First, we will generate the trading signals used to open positions using returns of the past business day.
$$ \text{signal}_i(t) = \begin{cases} 1 & \text{if } r_i(t-1) > 0 \\ 0 & \text{otherwise} \end{cases} $$where:
- $signal_i(t)$ is the signal used for stock $i$ at time $t$
- $r_i(t-1)$ is the return of stock $i$ at time $t-1$
## Calculate lagged returns
# First, we will shift the returns one business day forward, this will make returns of time t-1 available at time t.
egypt_bday = pd.tseries.offsets.CustomBusinessDay(weekmask='Sun Mon Tue Wed Thu')
shifted_date_index = rets.index.get_level_values('date') + egypt_bday
tuples = []
for asset, date in zip(rets.index.get_level_values('asset'), shifted_date_index):
tuples.append((asset, date))
shifted_multi_index = pd.MultiIndex.from_tuples(tuples)
lagged_rets = rets.copy()
lagged_rets.index = shifted_multi_index
# now we generate signals by setting positive returns to 1 and negative returns to 0
signals = (lagged_rets > 0).astype(int)
signals
ABUK 2001-05-24 1
2001-05-27 1
2001-05-30 0
2001-06-03 1
2001-06-06 0
..
TMGH 2024-07-09 0
2024-07-10 1
2024-07-11 0
2024-07-16 1
2024-07-17 0
Name: close, Length: 115333, dtype: int32
2.2. Calculating strategy returns (per position)¶
Now, we calculate the returns of our strategy at stocks level by multiplying the signals and stocks returns:
$$ r_{strategy,i}(t) = {signal_i(t)}*{r_i(t)} $$where:
- $r_{strategy,i}(t)$ is the return of stock $i$ at time $t$ if it was bought by the strategy.
- $signal_i(t)$ is the signal used for stock $i$ at time $t$
- $r_i(t-1)$ is the return of stock $i$ at time $t-1$
Then we drop all position with a return of 0 (i.e. the positions that weren't opened), which resulted from multiplication by 0 in the signals. This will leave us with returns of position we opened only.
## Calculate strategy returns
mutual_index = signals.index.intersection(rets.index)
strategy_stocks_rets = (signals.loc[mutual_index] * rets.loc[mutual_index]).dropna()
strategy_stocks_rets = strategy_stocks_rets.loc[strategy_stocks_rets!=0]
2.3. Subtracting trading fees¶
Before aggrigating returns for each day, we need to account for trading fees at both opening (buy) and closing (sell) transactions. The following table shows the transactions fees for each EGX transaction, disregarding brokerage fees, which we will ignore under the assumption that this strategy shall be implemented by licensed brokerage firm, with direct access to the exchange.
This makes the total transaction fees per order equan to 4bps, which we multiply by 2 to account for both opening and closing transactions, then we subtract it from the return of each position.
strategy_rets = strategy_stocks_rets-0.0008 ## Commission = 2 * (1 bps for EGX + 1 bps for MCDR + 1 bps for FRA + 1 bps for Risk Insurance + 0 Brokerage Fees)
2.4. Calculating strategy returns (per day)¶
Now, we aggrigate the returns for each day by calculating the average return of positions every day. This is consistent with the strategy logic where we open equally weighted positions.
$$ r_{\text{strategy}}(t) = \frac{\sum_{i=1}^{N} r_{\text{strategy},i}(t)}{N} $$where:
- $r_{strategy}(t)$ is the average return of the strategy at time $t$.
- $r_{strategy,i}(t)$ is the return of stock $i$ at time $t$ if it was bought by the strategy.
strategy_rets = strategy_rets.groupby(level=1).mean()
strategy_rets
2001-05-24 0.024517
2001-06-06 -0.005368
2001-06-07 -0.005308
2001-06-10 0.008847
2001-06-11 0.003657
...
2024-07-07 0.003719
2024-07-08 -0.026633
2024-07-09 -0.002026
2024-07-10 -0.017697
2024-07-16 0.004485
Name: close, Length: 4869, dtype: float64
3. Strategy Performance¶
Now that we finally have a return series for our strategy, we can start analyzing the strategy performance and calculate key metrics and visuals.
3.1. Key metrics¶
First, we will look at key financial metrics to compare the performance of our strategy with respect to EGX30 index. The following measures are calculated:
- Annualized Return
- Annualized Volatility
- Maximum Drawdown
- Sharpe Ratio
- Win Rate
Notes:
- The risk-free-rate used to calculate the Sharpe Ratio is the geometric mean of historical interest rates in Egypt. Interest rates data was downloaded from TradingView.
- Win Rate metric is calculated as:
full_dates = pd.date_range(start=strategy_rets.index.min(), end=strategy_rets.index.max(), freq='d')
measures_df = calculate_portfolio_measures(
pd.DataFrame(
{
"Strategy":strategy_rets.reindex(full_dates, fill_value=0),
"EGX30 Index": egx30_index_returns.loc[strategy_rets.index.min():strategy_rets.index.max()].reindex(full_dates, fill_value=0)
}
),
risk_free_rate=0.11,
periods_per_year=365
)
measures_df
| Annualized Return | Annualized Volatility | Maximum Drawdown | Sharpe Ratio | Win Rate | |
|---|---|---|---|---|---|
| Strategy | 0.569103 | 0.266155 | -0.566780 | 1.724947 | 0.567262 |
| EGX30 Index | 0.175776 | 0.251954 | -0.716035 | 0.261062 | 0.536941 |
3.2. Performance visualization¶
To better understand the performance of the strategy compared to the benchmark, we will create the following visuals:
- Compounded Growth graph
- Drowdown graph
- Returns distribution & Significance Analysis
plot_portfolio_performance(
pd.DataFrame(
{
"Strategy":strategy_rets.reindex(full_dates, fill_value=0),
"EGX30 Index": egx30_index_returns.loc[strategy_rets.index.min():strategy_rets.index.max()].reindex(full_dates, fill_value=0)
}
),
log_scale_growth=True,
bins=50
)
<Figure size 640x480 with 0 Axes>
4. Conclusions¶
The results show that even a simple signal generated based on past day return can be used to construct a profitable algorithmic trading strategy that significantly outperforms the EGX30 index. This study does not imply that this specific strategy would be profitable, however, it shows the huge potential of algorithmic trading in the egyptian stock market, given that more sophisticated algorithms were used to generate signals, and more appropriate risk management mechanisms were used to manage strategy risk.